SPDX-FileCopyrightText: 2026 Eole Fontaine Wolfs SPDX-FileCopyrightText: 2026 AlICe laboratory https://alicelab.be
SPDX-License-Identifier: GPL-3.0-or-later
import math
import random
import bmesh
import bpy
from mathutils import Euler, Matrix, VectorCONFIGURATION GLOBALE Toutes les constantes sont centralisées ici pour faciliter les ajustements
CUBE_SIZE = 10.0 # Taille du cube de délimitation
OFFSET = CUBE_SIZE / 2.0 # Décalage vertical pour centrer la boîte
THICK_K = 0.75 # Épaisseur des barres sur les temps forts (Kick)
THICK_S = 0.25 # Épaisseur des barres sur les temps faibles (Snare)
OVERSHOOT = 2.0 # Dépassement des barres hors de la boîte avant découpe
POS = [
-3.75,
-1.25,
1.25,
3.75,
] # Positions des 4 rangées/colonnes de la grille rythmiqueBase de données des moments musicaux Chaque entrée définit un “état” du générateur avec ses paramètres propres : rhythm : séquence Kick/Snare qui détermine l’épaisseur des barres wobble_amp : amplitude de la déformation de la surface fluide time_ratio : position temporelle dans le morceau (influence la hauteur du wobble) presence : intensité de la fréquence secondaire du wobble parasite_count : nombre de base de parasites générés parasite_types : types de parasites disponibles (‘Alpha’ = aiguille, ‘Beta’ = cône large)
MOMENTS = {
"1_The_Intro": {
"rhythm": ["K", "S", "K", "S"],
"wobble_amp": 1.0,
"time_ratio": 0.2,
"presence": 1.6,
"parasite_count": 0,
"parasite_types": [],
},
"2_The_Build_Up": {
"rhythm": ["K", "S", "K", "S"],
"wobble_amp": 1.3,
"time_ratio": 0.5,
"presence": 1.1,
"parasite_count": 8,
"parasite_types": ["Alpha"],
},
"3_The_Drop": {
"rhythm": ["K", "S", "K", "S"],
"wobble_amp": 2.0,
"time_ratio": 0.7,
"presence": 1.9,
"parasite_count": 15,
"parasite_types": ["Alpha", "Beta"],
},
"4_The_Bridge": {
"rhythm": ["K", "S", "K", "S"],
"wobble_amp": 1.5,
"time_ratio": 0.3,
"presence": 0.7,
"parasite_count": 12,
"parasite_types": ["Alpha"],
},
"5_The_Chaos": {
"rhythm": ["K", "S", "K", "S"],
"wobble_amp": 2.3,
"time_ratio": 0.75,
"presence": 0.3,
"parasite_count": 18,
"parasite_types": ["Alpha", "Beta"],
},
"6_The_Outro": {
"rhythm": ["K", "S", "K", "S"],
"wobble_amp": 1.0,
"time_ratio": 0.3,
"presence": 0.6,
"parasite_count": 5,
"parasite_types": [],
},
}UTILITAIRES
Masque toutes les collections Génération_X déjà existantes dans le viewport
def hide_previous_generations():sans les supprimer, pour garder un affichage lisible lors des générations successives.
layer_coll = bpy.context.view_layer.layer_collection
for child in layer_coll.children:
if child.collection.name.startswith("Génération_"):
child.hide_viewport = True
print(f" Masquée : {child.collection.name}")Crée une nouvelle collection nommée ‘Génération_N’ où N est
def next_generation_collection():automatiquement incrémenté à partir des collections existantes.
indices = []
for c in bpy.data.collections:
if c.name.startswith("Génération_"):
try:
indices.append(int(c.name.split("_")[1]))
except (IndexError, ValueError):
pass
n = max(indices) + 1 if indices else 1
coll = bpy.data.collections.new(f"Génération_{n}")
bpy.context.scene.collection.children.link(coll)
return collForce la collection active dans le layer_collection,
def set_active_collection(coll):ce qui garantit que les primitive_add s’y placent directement.
layer_coll = bpy.context.view_layer.layer_collection def find_layer(lc, target):
if lc.collection == target:
return lc
for child in lc.children:
result = find_layer(child, target)
if result:
return result
return None
lc = find_layer(layer_coll, coll)
if lc:
bpy.context.view_layer.active_layer_collection = lcFusionne les vertices en double et recalcule les normales
def topology_clean(obj):pour garantir un mesh propre avant les opérations booléennes.
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.0001)
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(obj.data)
bm.free()Applique un modificateur booléen et le résout immédiatement.
def apply_boolean(obj, name, cutter, operation="INTERSECT", solver="EXACT"):use_self et use_hole_tolerant activés pour éviter les artefacts sur les géométries complexes avec auto-intersections.
mod = obj.modifiers.new(name, "BOOLEAN")
mod.operation = operation
mod.object = cutter
mod.solver = solver
if solver == "EXACT":
mod.use_self = True
mod.use_hole_tolerant = True
bpy.context.view_layer.objects.active = obj
bpy.ops.object.modifier_apply(modifier=name)GÉOMÉTRIE — WOBBLE (surface fluide)
Génère la surface fluide principale à partir d’une grille subdivisée.
def build_wobble(dna, coll):La hauteur de chaque vertex est calculée par superposition de trois ondes sinusoïdales paramétrées par wobble_amp, time_ratio et presence. La surface est ensuite extrudée vers le bas pour former un volume fermé, nécessaire aux opérations booléennes ultérieures.
Phases aléatoires pour éviter la symétrie entre les générations
px, py = random.uniform(0, math.pi * 2), random.uniform(0, math.pi * 2)
base_z = (
2.0 + dna["time_ratio"] * 6.0
) # Hauteur de base selon la position temporelle
fa = 0.5 # Fréquence principale (constante)
fb = 0.85 * dna["presence"] # Fréquence secondaire (modulée par presence)
set_active_collection(coll)
bpy.ops.mesh.primitive_grid_add(
x_subdivisions=48,
y_subdivisions=48,
size=CUBE_SIZE + OVERSHOOT * 2,
location=(0, 0, 0),
)
obj = bpy.context.active_object
obj.name = "Wobble"Déformation Z de chaque vertex par combinaison de trois ondes
for v in obj.data.vertices:
wave = (
math.sin(v.co.x * fa + px)
+ math.cos(v.co.y * fa + py)
+ 0.5 * math.sin((v.co.x - v.co.y) * fb)
)
v.co.z = base_z + dna["wobble_amp"] * wave * 0.35Subdivision légère pour lisser la surface avant extrusion
sub = obj.modifiers.new("Sub", "SUBSURF")
sub.levels = 1
bpy.ops.object.modifier_apply(modifier="Sub")Extrusion vers le bas pour fermer le volume (requis pour les booleans)
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.extrude_region_move(TRANSFORM_OT_translate={"value": (0, 0, -20.0)})
bpy.ops.object.mode_set(mode="OBJECT")Aplatissement du bas pour éviter les artefacts sous la scène
bm = bmesh.new()
bm.from_mesh(obj.data)
for v in bm.verts:
if v.co.z < 0:
v.co.z = -5.0
bm.to_mesh(obj.data)
bm.free()
topology_clean(obj)
for p in obj.data.polygons:
p.use_smooth = True # Shading lisse pour la surface fluide
return objGÉOMÉTRIE — BEAT MATRIX (grille rythmique)
Génère la grille de barres orthogonales représentant le rythme.
def build_beat_matrix(dna, coll):Trois orientations de barres (X, Y, Z) sont croisées aux 16 intersections de la grille 4x4. Les barres Z (piliers) sont toujours présentes. Les barres X et Y (horizontales) sont supprimées aléatoirement à 25% pour alléger la structure visuellement et réduire la charge booléenne.
bm = bmesh.new()
rhythm = dna["rhythm"]
for i, px in enumerate(POS):
for j, py in enumerate(POS):Épaisseur selon la position dans le pattern rythmique
t = THICK_K if rhythm[(i + j) % 4] == "K" else THICK_SMatrices de transformation pour chaque orientation de barre
mz = Matrix.LocRotScale(
Vector((px, py, OFFSET)),
None,
Vector((t, t, CUBE_SIZE + OVERSHOOT)),
)
my = Matrix.LocRotScale(
Vector((px, 0, py + OFFSET)),
None,
Vector((t, CUBE_SIZE + OVERSHOOT, t)),
)
mx = Matrix.LocRotScale(
Vector((0, px, py + OFFSET)),
None,
Vector((CUBE_SIZE + OVERSHOOT, t, t)),
)
bmesh.ops.create_cube(
bm,
size=1.0,
matrix=mz,
) # Pilier vertical : toujours présent
if random.random() > 0.25: # Poutre Y : 75% de chance d'apparaître
bmesh.ops.create_cube(bm, size=1.0, matrix=my)
if random.random() > 0.25: # Poutre X : 75% de chance d'apparaître
bmesh.ops.create_cube(bm, size=1.0, matrix=mx)
mesh = bpy.data.meshes.new("Beat_Matrix")
bm.to_mesh(mesh)
bm.free()
obj = bpy.data.objects.new("Beat_Matrix", mesh)
coll.objects.link(obj)
topology_clean(obj)
return objGÉOMÉTRIE — PARASITES (piques organiques)
Construit un prisme triangulaire effilé (pique) dans un bmesh existant.
def build_spike(bm, origin, direction, length, radius, p_type="Alpha"):La base est un triangle équilatéral, le sommet est réduit selon le type : - Alpha : très effilé (ratio 0.15) — simule une aiguille - Beta : peu effilé (ratio 0.60) — simule un cône large
La transformation finale aligne l’axe Z local sur la direction donnée, avec une légère inclinaison aléatoire pour casser la rigidité.
r_top = radius * (0.15 if p_type == "Alpha" else 0.6)Vertices de la base triangulaire (plan XY local)
vbase = [
(0, radius, 0),
(radius * 0.866, -radius * 0.5, 0),
(-radius * 0.866, -radius * 0.5, 0),
]Vertices du sommet (triangle réduit à hauteur length)
vtop = [
(0, r_top, length),
(r_top * 0.866, -r_top * 0.5, length),
(-r_top * 0.866, -r_top * 0.5, length),
]
vs = [bm.verts.new(co) for co in vbase + vtop]
v1, v2, v3, v4, v5, v6 = vsConstruction des 5 faces du prisme
bm.faces.new((v1, v3, v2)) # Face de base
bm.faces.new((v4, v5, v6)) # Face du sommet
bm.faces.new((v1, v2, v5, v4)) # Face latérale 1
bm.faces.new((v2, v3, v6, v5)) # Face latérale 2
bm.faces.new((v3, v1, v4, v6)) # Face latérale 3Légère inclinaison aléatoire pour casser la symétrie radiale
tilt = Euler((random.uniform(-math.radians(15), math.radians(15)),) * 3, "XYZ")
rot = Vector((0, 0, 1)).rotation_difference(direction.normalized())
mat = (
Matrix.Translation(origin)Génère l’ensemble des parasites selon les paramètres du moment musical.
@ rot.to_matrix().to_4x4()
@ tilt.to_matrix().to_4x4()
)
for v in vs:
v.co = mat @ v.co
def build_parasites(dna, coll):Le nombre final fluctue légèrement autour de la valeur de base (±2/3). Chaque parasite est positionné aléatoirement dans la boîte de délimitation, avec des paramètres géométriques distincts selon son type.
Variation stochastique du nombre de parasites
count = dna["parasite_count"] + (
random.randint(-2, 3) if dna["parasite_count"] > 10 else 0
)
if not dna["parasite_types"] or count <= 0:
print(" Parasites : aucun pour ce moment")
return None, 0
bm = bmesh.new()
for _ in range(count):
p_type = random.choice(dna["parasite_types"])
if p_type == "Alpha":Alpha : grande aiguille tombante depuis le haut de la boîte
l = 15.0
r = random.uniform(0.4, 1.0)
z0 = CUBE_SIZE - 0.2
else:Beta : cône court dispersé dans le volume
l = random.uniform(1.5, 3.0)
r = random.uniform(0.7, 1.2)
z0 = random.uniform(2.0, CUBE_SIZE - 2.0)Position XY libérée de la grille pour une dispersion organique
origin = Vector((random.uniform(-4.5, 4.5), random.uniform(-4.5, 4.5), z0))
build_spike(bm, origin, Vector((0, 0, -1)), l, r, p_type)
mesh = bpy.data.meshes.new("Parasites")
bm.to_mesh(mesh)
bm.free()
obj = bpy.data.objects.new("Parasites", mesh)
coll.objects.link(obj)
topology_clean(obj)
return obj, countSYNTHÈSE PRINCIPALE
Point d’entrée de la génération. Sélectionne aléatoirement un moment
def synthesize():musical, crée la collection de destination, construit les trois géométries (wobble, beat matrix, parasites) puis applique la chaîne de booleans :
moment_name, dna = random.choice(list(MOMENTS.items()))
coll = next_generation_collection()
print(f"\n{'=' * 52}")
print(f" {coll.name} — {moment_name}")
print(f"{'=' * 52}")— Limit Box — Cube invisible utilisé uniquement comme cutter booléen final. display_type BOUNDS l’affiche en fil de fer sans alourdir la scène.
set_active_collection(coll)
bpy.ops.mesh.primitive_cube_add(size=CUBE_SIZE, location=(0, 0, OFFSET))
box = bpy.context.active_object
box.name = "Limit_Box"
box.display_type = "BOUNDS"
box.hide_render = True— Construction des géométries —
wobble = build_wobble(dna, coll)
beat = build_beat_matrix(dna, coll)
para, count = build_parasites(dna, coll)— Chaîne booléenne —
apply_boolean(beat, "Slice", wobble, "DIFFERENCE")
apply_boolean(beat, "Limit", box, "INTERSECT")
if para:
apply_boolean(para, "Slice", wobble, "DIFFERENCE")
apply_boolean(para, "Limit", box, "INTERSECT")
apply_boolean(wobble, "Limit", box, "INTERSECT")Suppression de la Limit Box après usage (elle ne doit pas rester en scène)
bpy.data.objects.remove(box, do_unlink=True)— Rapport console —
print(f" Amplitude : {dna['wobble_amp']}")
print(f" Z-Ratio : {dna['time_ratio']}")
print(f" Présence : {dna['presence']}")
print(f" Parasites : {count}")
print(f"{'=' * 52}\n")EXÉCUTION Appeler synthesize() plusieurs fois pour générer plusieurs configurations dans des collections distinctes (Génération_1, Génération_2, etc.)
if __name__ == "__main__":
hide_previous_generations() # Masque les générations existantes
synthesize() # Génère la nouvelle dans une collection visible